<?php
/**
 * 微擎模块核心类
 * 
 * [WeEngine System] Copyright (c) 2013 WE7.CC
 */
defined('IN_IA') or exit('Access Denied');

class WeEngine {

    private $token = '';

    private $events = array();

    private $modules = array();

    private $matcher = null;

    /**
     * 构造新实例
     * @param array $config 设置项
     *  - token string 微信密钥
     *  - matcher callable 此回调函数参数提供扫描目标字符串并按照关键字匹配出模块名称的能力, 参入为输入字符串, 返回结果为元组数据
     *      modules : 
     *          模块名称集合. 键名为 模块名, 键值为 匹配次数, 按照匹配次数倒序排列
     *      rules :
     *          所有匹配的关键字规则集合, 格式:
     *          关键字对象集合元素的成员包括 id weid name module
     *  - modules(optional) array 当前系统允许的模块集合, 不在此集合的模块不会进行处理, default welcome 模块不能禁用
     *  - before(optional) 开始执行消息处理之前执行, 传递参数为消息对象. 参阅 WeUtility::parse 结果, 返回结果如果为 false 将中断执行
     *  - after(optional) 完成消息处理之后执行, 传递参数为消息对象. 参阅 WeUtility::parse 结果, 返回结果如果为 false 将中断执行
     */
    public function __construct($config) {
    	if(empty($config['token'])) {
            exit('initial missing token');
        }
        $this->token = $config['token'];
        if(empty($config['modules']) || !is_array($config['modules'])) {
            $this->modules = array();
        } else {
            $this->modules = $config['modules'];
        }
        $this->modules[] = 'welcome';
        $this->modules[] = 'default';
        $this->modules = array_unique($this->modules);
        if(empty($config['matcher']) || !is_callable($config['matcher'])) {
            exit('initial missing matcher');
        }
        $this->matcher = $config['matcher'];
        if(!empty($config['before'])) {
            $this->events['before'] = $config['before'];
        }
        if(!empty($config['after'])) {
            $this->events['after'] = $config['after'];
        }
    }



    public function start() {
        if(empty($this->token)) {
            exit('Access Denied');
        }
        if(!WeUtility::checkSign($this->token)) {
            //exit('Access Denied');
        }
        if(strtolower($_SERVER['REQUEST_METHOD']) == 'get') {
            // save testing
            exit($_GET['echostr']);
        }
        if(strtolower($_SERVER['REQUEST_METHOD']) == 'post') {
            $postStr = $GLOBALS["HTTP_RAW_POST_DATA"];
            $message = WeUtility::parse($postStr);
            WeUtility::logging('debug', $message);
            if(!empty($message)) {
                if(is_callable($this->events['before']) && call_user_func($this->events['before'], $message) === false) {
                    exit('before event break');
                }
                session_id(md5($this->message['from'] . $this->message['to']));
                session_start();
                $response = array();
                $modules = array();
                if($message['type'] == 'subscribe') {
                    $modules = array('welcome' => 1);
                    $response = $this->process($modules, $message);
                } elseif ($message['type'] == 'text') {
                    $result = call_user_func($this->matcher, $message['content']);
                    $response = $this->process($result['modules'], $message, $result['rules']);
                } elseif ($message['type'] == 'location') {
                } 
                if(empty($response) || ($response['type'] == 'text' && empty($response['content'])) || ($response['type'] == 'news' && empty($response['items']))) {
                    $modules = array('default' => 1);
                    $response = $this->process($modules, $message);
                }
                if(is_callable($this->events['after']) && call_user_func($this->events['after'], $message, $modules) === false) {
                    exit('after event break');
                }
                exit(WeUtility::response($response));
            }
        }
        exit('Request Failed');
    }

    private function process($modules, $message, $rules = array()) {
        global $_W;
        $response = null;
        $incontext = false;
        if(WeUtility::inContext() && in_array($_SESSION['contextmodule'], $this->modules)) {
            $modules = array_merge(array($_SESSION['contextmodule'] => 1), $modules);
            $incontext = true;
        }
        if(!empty($modules)) {
            foreach($modules as $m => $c) {
                if(!in_array($m, $this->modules)) {
                    continue;
                }
                $_W['module'] = $m;
                $processor = WeUtility::createModuleProcessor($m);
                $processor->message = $message;
                $processor->inContext = $incontext;
                $processor->rules = $rules;
                if($i = $processor->isNeedInitContext()) {
                    if(is_array($_SESSION['contexts'])) {
                        $processor->contexts = array_slice($_SESSION['contexts'], 0, $i);
                    }
                }
                $response = $processor->respond();
                if($processor->isNeedSaveContext()) {
                    $processor->response = $response;
                    if(empty($_SESSION['contexts'])) {
                        $_SESSION['contexts'] = array();
                        array_unshift($_SESSION['contexts'], $processor);
                    }
                }
                if(!empty($response)) {
                    break;
                }
            }
        }
        return $response;
    }
}

class WeUtility {
    public static function rootPath() {
        static $path;
        if(empty($path)) {
            $path = dirname(__FILE__);
            $path = str_replace('\\', '/', $path);
        }
        return $path;
    }

    public static function checkSign($token) {
        $signkey = array($token, $_GET['timestamp'], $_GET['nonce']);
        sort($signkey);
        $signString = implode($signkey);
        $signString = sha1($signString);
        if($signString == $_GET['signature']){
            return true;
        }else{
            return false;
        }
    }

    public static function createModuleProcessor($name) {
        $classname = "{$name}ModuleProcessor";
        if(!class_exists($classname)) {
            $file = WeUtility::rootPath() . "/{$name}/processor.php";
            if(!is_file($file)) {
                trigger_error('ModuleProcessor Definition File Not Found '.$file, E_USER_ERROR);
                return null;
            }
            require $file;
        }
        if(!class_exists($classname)) {
            trigger_error('ModuleProcessor Definition Class Not Found', E_USER_ERROR);
            return null;
        }
        $o = new $classname();
        if($o instanceof WeModuleProcessor) {
            return $o;
        } else {
            trigger_error('ModuleProcessor Class Definition Error', E_USER_ERROR);
            return null;
        }
    }

    /**
     * 分析请求数据
     * @param string $request 接口提交的请求数据
     * @return array 请求数据结构
     *  - from 请求用户
     *  - to 目标用户
     *  - time 请求时间
     *  - type 请求类型, 目前包括 text: 普通文本请求, hello: 加关注, location: 位置信息
     *      - text 类型
     *          - content 请求内容
     *      - hello 类型
     *          - 无附加内容
     *      - location 类型
     *          - x 纬度
     *          - y 经度
     *          - scale 缩放精度
     *          - label 位置信息描述
     */
    public static function parse($message) {
        $packet = array();
        if (!empty($message)){         
            $obj = simplexml_load_string($message, 'SimpleXMLElement', LIBXML_NOCDATA);
            if($obj instanceof SimpleXMLElement) {
                $packet['from'] = strval($obj->FromUserName);
                $packet['to'] = strval($obj->ToUserName);
                $packet['time'] = strval($obj->CreateTime);
                $packet['type'] = strval($obj->MsgType);
                $packet['event'] = strval($obj->Event);
                if($packet['type'] == 'text') {
                    $packet['content'] = strval($obj->Content);
                }
                if($packet['type'] == 'location') {
                    $packet['x'] = strval($obj->Location_X);
                    $packet['y'] = strval($obj->Location_Y);
                    $packet['scale'] = strval($obj->Scale);
                    $packet['label'] = strval($obj->Label);
                }
                if($packet['type'] == 'event') {
                    $packet['type'] = $packet['event'];
                    unset($packet['content']);
                }
            }
        }
        return $packet;
    }

    /**
     * 按照响应内容组装响应数据
     * @param array $packet 响应内容
     *  - to 目标用户, 一般为请求的来源用户
     *  - from 发送用户, 一般为请求的目标用户
     *  - time(optional) 发送时间, 默认为当前时间
     *  - star(optional) 星标消息, 如果此内容为真, 则会在微信平台上为此消息加星, 默认不加星
     *  - type(optional) 消息类型(可选值: text, news), 默认为普通文本(text)
     *      - text 类型, 普通文本, 包括以下附加信息
     *          - content 消息内容
     *      - news 类型, 图文信息, 包括以下附加信息
     *          - content(pendding) 消息内容, 目前不可用
     *          - items 图文信息集合, 可以为多条. 目前仅可用 1 条, 成员元素包括
     *              - title 图文信息标题
     *              - description 图文信息描述
     *              - picurl 图文信息图片
     *              - url 图文信息目标链接
     *
     * @return string
     */
    public static function response($packet) {
        if(empty($packet['CreateTime'])) {
            $packet['CreateTime'] = time();
        }
        if(empty($packet['MsgType'])) {
            $packet['MsgType'] = 'text';
        }
        if(empty($packet['FuncFlag'])) {
        	$packet['FuncFlag'] = 0;
        } else {
        	$packet['FuncFlag'] = 1;
        }
        return self::array2xml($packet);
    }

    public static function beginContext($nextmodule = '', $expire = 3600) {
        global $_W;
        if(empty($nextmodule)) {
            $nextmodule = $_W['module'];
        }
        $_SESSION['contextmodule'] = $nextmodule;
        $_SESSION['contextexpire'] = TIMESTAMP + $expire;
    }

    public static function endContext() {
        $contexts = $_SESSION['contexts'];
        session_unset();
        $_SESSION['contexts'] = $contexts;
    }

    public static function inContext() {
        if(!empty($_SESSION['contextmodule']) && !empty($_SESSION['contextexpire'])) {
            return $_SESSION['contextexpire'] > TIMESTAMP;
        }
        return false;
    }

    public static function setContext($key, $value) {
        if(!WeUtility::inContext()) {
            return;
        }
        if(!is_array($_SESSION['vals'])) {
            $_SESSION['vals'] = array();
        }
        if(!isset($value)) {
            unset($_SESSION['vals'][$key]);
        } else {
            $_SESSION['vals'][$key] = $value;
        }
    }

    public static function getContext($key) {
        if(!WeUtility::inContext()) {
            return null;
        }
        return $_SESSION['vals'][$key];
    }

    public static function logging($level = 'info', $message = '') {
        if(!DEVELOPMENT) {
            return true;
        }
        $filename = IA_ROOT . '/data/logs/' . date('Ymd') . '.log';
        mkdirs(dirname($filename));
        $content = date('Y-m-d H:i:s') . " {$level} :\n------------\n";
        if(is_string($message)) {
            $content .= "String:\n{$message}\n";
        }
        if(is_array($message)) {
            $content .= "Array:\n";
            foreach($message as $key => $value) {
                $content .= sprintf("%s : %s ;\n", $key, $value);
            }
        }
        if($message == 'get') {
            $content .= "GET:\n";
            foreach($_GET as $key => $value) {
                $content .= sprintf("%s : %s ;\n", $key, $value);
            }
        }
        if($message == 'post') {
            $content .= "POST:\n";
            foreach($_POST as $key => $value) {
                $content .= sprintf("%s : %s ;\n", $key, $value);
            }
        }
        $content .= "\n";

        $fp = fopen($filename, 'a+');
        fwrite($fp, $content);
        fclose($fp);
    }
    
    public static function array2xml($arr, $level = 1, $ptagname = '') {
    	$s = $level == 1 ? "<xml>" : '';
    	foreach($arr as $tagname => $value) {
    		if (is_numeric($tagname)) {
    			$tagname = $value['TagName'];
    			unset($value['TagName']);
    		}
    		if(!is_array($value)) {
    			$s .= "<{$tagname}>".(!is_numeric($value) ? '<![CDATA[' : '').$value.(!is_numeric($value) ? ']]>' : '')."</{$tagname}>";
    		} else {
    			$s .= "<{$tagname}>".self::array2xml($value, $level + 1)."</{$tagname}>";
    		}
    	}
    	$s = preg_replace("/([\x01-\x08\x0b-\x0c\x0e-\x1f])+/", ' ', $s);
    	return $level == 1 ? $s."</xml>" : $s;
    }
}

/**
 * 功能模块定义, 用户定义独立的功能用于应答请求. 
 * 工作流程:
 *      - 编辑匹配规则时, 如果 $isNeedExtendFields 为真, 则在编辑表单中嵌入并扩展所需的字段( 通过 fieldsFormDisplay, fieldsFormValidate fieldsFormSubmit 三个成员函数)
 *      - 配置参数, 如果 $settings 不为空, 则在模块编辑的界面中按照 $settings 定义增加配置参数选项. 调用时包含 modules 目录下定义使用模块标识(tolowwer)为名的子目录中的文件.
 *        如: 如果 ChatRobot 模块存在配置项为 'settings' => '配置参数', 那么将在模块配置的界面中显示链接 "配置参数", 点击后的目标页面将会包含 /source/modules/chatrobot/settings.inc.php
 */
abstract class WeModule {

    /**
     * 模块标识, 如ChatRobot, WeatherReporter; 与对应实现类名称对应 ChatRobotModule, WeatherReporterModule
     */
    public $name;

    /**
     * 模块名称, 如聊天 天气预报
     */
    public $title;

    /**
     * 功能描述, 用于自动生成菜单, 请使用动词描述此模块. 如 陪你聊天, 帮你查天气预报
     */
    public $ability;

    /**
     * 是否需要扩展规则匹配字段
     */
    public $isNeedExtendFields;

    /**
     * 需要显示的独立的配置参数页面
     * 格式:
     *      配置项(亦即文件名) => 配置项名称
     */
    public $settings;

    /**
     * 需要附加至规则表单的字段内容, 编辑规则时如果类型为当前模型, 则调用此方法将返回内容附加至规则表单之后
     * @param int $rid 如果操作为更新规则, 则此参数传递为规则编号, 如果为新建此参数为 0
     * @return string 要附加的内容(html格式)
     */
    abstract function fieldsFormDisplay($rid = 0);

    /**
     * 验证附加至规则表单的字段内容, 编辑规则时如果类型为当前模型, 则在保存规则之前调用此方法验证自定义字段的有效性
     * @param int $rid 如果操作为更新规则, 则此参数传递为规则编号, 如果为新建此参数为 0
     * @return string 验证的结果, 如果为空字符串则表示验证成功, 否则返回验证失败的提示信息 
     */
    abstract function fieldsFormValidate($rid = 0);

    /**
     * 编辑规则时如果类型为当前模型, 则在提交成功后调用此方法
     * @param int $rid 规则编号
     * @return void
     */
    abstract function fieldsFormSubmit($rid = 0);
    /**
     * 在列表中删除规则如果类型为当前模型，则在删除成功后调用此方法，做一些删除清理工作。
     * @param int $rid 规则id,必填值
     * @return <boolean | error> 如果操作成功则返回True,否则返回error信息
     */
    abstract function ruleDeleted($rid = 0);
    
    public function template($filename, $flag = TEMPLATE_INCLUDEPATH) {
    	global $_W;
    	list($path, $filename) = explode(':', $filename);
	    $source = IA_ROOT . "/source/modules/$path/template/{$filename}.html";  
	    if(!is_file($source)) {
	        $source = "{$_W['template']['source']}/{$_W['template']['current']}/{$filename}.html";
	    }
	    if(!is_file($source)) {
	        exit("Error: template source '{$filename}' is not exist!");
	    }
	    $compile = "{$_W['template']['compile']}/{$_W['template']['current']}/{$path}/{$filename}.tpl.php";
	    if (DEVELOPMENT || !is_file($compile) || filemtime($source) > filemtime($compile)) {
	        template_compile($source, $compile);
	    }
	    switch ($flag) {
	    	case TEMPLATE_DISPLAY:
	    	default:
	    		extract($GLOBALS, EXTR_SKIP);
	    		include $compile;
	    		break;
	    	case TEMPLATE_FETCH:
	    		extract($GLOBALS, EXTR_SKIP);
	    		ob_start();
	    		ob_clean();
	    		include $compile;
	    		$contents = ob_get_contents();
	    		ob_clean();
	    		return $contents;
	    		break;
	    	case TEMPLATE_INCLUDEPATH:
	    		return $compile;
	    		break;
	    	case TEMPLATE_CACHE:
	    		exit('暂未支持');
	    		break;
	    }
    }
}

/**
 * 模块处理程序, 当匹配此模块的消息到来时, 使用此对象处理对象并返回处理结果
 * 流程: 
 *      - 系统会自自动初始化 $message, $inContext, $rules, $modules 成员. 如果为上下文响应对话, 则不会初始化 $rules 成员
 *      - 系统调用 isNeedInitContext 方法, 判断是否需要初始化上下文环境, 如果需要则 初始化 $contexts 成员
 *      - 系统调用 respond 方法, 获取响应内容并返回给微信接口
 *      - 系统调用 isNeedSaveContext 方法, 判断是否需要保存本次会话至上下文环境, 如果需要保存 则将上一步返回的响应内容保存至 $response 成员, 然乎将本实例保存至上下文对象中
 *      - 调用结束
 *  注意:
 *      此对象可能被序列化保存以达成上下文传递. 因此, 你可能需要使用魔术函数 __sleep 和 __wakeup 方法进行特定清理和初始化操作.
 */
abstract class WeModuleProcessor {
    /**
     * 模块标识, 如ChatRobot, WeatherReporter; 与对应实现类名称对应 ChatRobotModuleProcessor, WeatherReporterModuleProcessor
     */
    public $name;

    /**
     * 本次请求消息, 此属性由系统初始化
     */
    public $message;

    /**
     * 本次对话是否未上下文响应对话
     */
    public $inContext;

    /**
     * 本次请求所匹配的规则, 此属性由系统初始化
     */
    public $rules;

    /**
     * 本次请求所匹配的处理模块, 此属性由系统初始化
     */
    public $modules;

    /**
     * 响应内容, 注意此成员仅在对象作为上下文时有用. 系统不会使用此成员的内容响应至微信接口
     */
    public $response;

    /**
     * 本次处理的上下文环境, 说明:
     * 上下文环境针对用户相关, 不同的用户保存为独立的上下文.
     * 存储方式理解为 ModuleProcessor 对象栈, 即最后一次对话保存在栈顶, 第一次对话保存在栈底, 每个元素为一个 ModuleProcessor 对象实例
     * 此成员仅按照 isNeedInitContext 方法方式初始化
     */
    public $contexts;

    /**
     * 判断处理程序是否需要上下文环境
     * @return int 如果返回 0, 代表不需要上下文, 如果返回大于 0 的数值, 将初始化指定条数的上下文数据, 如果返回 -1 将处理所有上下文数据
     */
    abstract function isNeedInitContext();

    /**
     * 应答此条请求, 如果响应内容为空. 将越过此模块调用更低层的模块
     * @return string
     */
    abstract function respond();

    /**
     * 判断是要需要保存本次对话至上下文环境
     * @return bool
     */
    abstract function isNeedSaveContext();
}
